Skip to content

Fix duplicate FP8 state updates during activation checkpoint recomputation#3213

Open
AlbertYang514 wants to merge 3 commits into
NVIDIA:mainfrom
AlbertYang514:fix/checkpoint-fp8-update-bookkeeping-main
Open

Fix duplicate FP8 state updates during activation checkpoint recomputation#3213
AlbertYang514 wants to merge 3 commits into
NVIDIA:mainfrom
AlbertYang514:fix/checkpoint-fp8-update-bookkeeping-main

Conversation

@AlbertYang514

@AlbertYang514 AlbertYang514 commented Jul 15, 2026

Copy link
Copy Markdown

Description

When DelayedScaling is used with multiple Transformer Engine modules under activation checkpointing, FP8 global state update ownership is incorrectly handled during recomputation.

Checkpoint recomputation introduces nested FP8 autocast scopes. These scopes can independently advance forward amax/scale state, causing one forward update per recomputed checkpoint segment instead of one update per logical forward pass.

With reentrant checkpointing, recomputed modules can also repeatedly reacquire first-module ownership. This causes backward amax reduction and scale updates to execute once per recomputed module instead of once per logical backward pass.

As a result, FP8 state may be advanced multiple times within a single logical microbatch.

Minimal reproduction

The issue reproduces using only public PyTorch and Transformer Engine APIs:

  • transformer_engine.pytorch.Linear
  • Transformer Engine checkpointing
  • FP8 autocast
  • DelayedScaling
  • Format.HYBRID
  • Three sequential te.Linear modules

The reproduction does not depend on a model framework, distributed runtime, external dataset, saved checkpoint, or application-specific code.

On unmodified Transformer Engine main at commit 70957ad5aa8337a12c254e86a6e0b7ffdbbd4b59:

Checkpoint configuration Forward updates Backward updates
No checkpoint 1 1
Non-reentrant, single segment 2 1
Non-reentrant, per-layer segments 4 1
Reentrant, single segment 2 3
Reentrant, per-layer segments 4 3
Nested reentrant 3 2

Expected for every configuration:

forward FP8 state updates:  1
backward FP8 state updates: 1

The loss, input gradient, and all weight gradients remain finite and nonzero in the minimal reproduction. The incorrect state progression therefore does not necessarily produce an immediate runtime error.

The results were reproduced across three seeds before applying the fix.

Root cause

is_first_fp8_module is stored in process-global FP8 state, while ownership of the corresponding update belongs to the surrounding logical FP8 autocast and checkpoint frame.

During activation recomputation:

  1. Each recomputation autocast scope can be treated as a new forward update boundary.
  2. Reentrant checkpoint frames restore saved first-module state.
  3. First-module ownership can be restored after it has already been queried and consumed.
  4. Subsequent recomputed modules can therefore acquire the same ownership again.

This causes:

  • forward FP8 state updates once per recomputed checkpoint segment;
  • backward FP8 state reduction and update once per recomputed module under reentrant checkpointing.

The affected state transitions involve:

transformer_engine/pytorch/distributed.py
transformer_engine/pytorch/quantization.py
transformer_engine/pytorch/module/linear.py
transformer_engine/pytorch/module/layernorm_linear.py
transformer_engine/pytorch/module/layernorm_mlp.py

Fix

The fix makes FP8 update ownership checkpoint-frame-aware and preserves it across activation recomputation.

The implementation:

  • saves and restores first-module ownership independently for each checkpoint frame;
  • reserves the single backward-update ownership during the original no-grad forward of a reentrant checkpoint;
  • allows recomputation to consume that ownership normally without restoring it after every module;
  • prevents recomputation autocast scopes from establishing additional logical forward-update boundaries;
  • prevents recomputation autocast exit from independently advancing forward DelayedScaling state;
  • restores the surrounding FP8 autocast and recomputation state when a checkpoint frame exits;
  • applies the checkpoint-aware ownership handling consistently to Linear, LayerNormLinear, and LayerNormMLP.

After the change, Transformer Engine performs exactly one forward and one backward global FP8 state update for each logical microbatch.

No manual FP8 update call or application-level workaround is required.

Tests

A regression test is added at:

tests/pytorch/test_reentrant_fp8_updates.py

The tests cover:

  • no checkpoint;
  • non-reentrant checkpointing with one segment;
  • non-reentrant checkpointing with one segment per layer;
  • reentrant checkpointing with one segment;
  • reentrant checkpointing with one segment per layer;
  • nested reentrant checkpointing;
  • restoration of surrounding FP8 autocast state;
  • finite and nonzero input and weight gradients.

Before the fix:

6 failed, 2 passed

All six failures were caused by forward or backward update counts differing from 1.

After the fix:

8 passed

The complete six-path matrix was also run with five seeds:

30/30 runs passed
forward updates:  1
backward updates: 1
loss: finite
input gradients: finite and nonzero
weight gradients: finite and nonzero
surrounding FP8 state: restored

Related existing tests also passed:

  • full activation recompute with reentrant and non-reentrant DelayedScaling;
  • reentrant activation recompute with CurrentScaling;
  • recipe, scale, and quantizer-state tests;
  • Linear, LayerNormLinear, and LayerNormMLP backward tests;
  • nested FP8 autocast and torch.compile coverage;
  • PyTorch L0 lint;
  • license L0;
  • Black, syntax, and whitespace checks.

The full tests/pytorch/test_checkpoint.py compatibility suite was not runnable locally because the checkout did not contain its required pre-generated .pt artifacts. The affected cases exited with FileNotFoundError before entering the Transformer Engine checkpoint-loading path.

Real-world impact

The issue was discovered during single-GPU pretraining of a 1.68B-parameter decoder model on a 24 GB NVIDIA GeForce RTX 5090 D v2. Reentrant activation checkpointing was required to fit the workload in device memory.

The model contained 196 Transformer Engine Linear modules. Before the workaround, each microbatch executed:

forward FP8 state updates:  29
backward FP8 state updates: 196

With gradient accumulation over 64 microbatches, this resulted in:

forward updates per optimizer step:   1,856
backward updates per optimizer step: 12,544

Because FP8 amax and scale state was advanced while backward propagation was still in progress, later modules in the same backward pass could consume different state from earlier modules.

This produced execution-order-sensitive dgrad corruption. Observed examples included transitions from gradients on the order of 1e-6 to values above 300, and intermittent pre-clipping global gradient-norm spikes as high as 629406.

The affected runs continued to report finite losses, making the failure easy to miss when only loss values or post-clipping gradients were monitored.

After restoring one forward and one backward update per logical microbatch:

  • update counts became exactly 1/1;
  • the previously observed gradient explosions no longer occurred;
  • measured gradient norms were within approximately 2% of the BF16 reference in the tested steps;
  • parameter update norms remained stable;
  • the FP8 throughput benefit was preserved at approximately 13.2k tokens/s.

This workload is included only as additional impact validation. The regression tests and minimal reproduction use public Transformer Engine APIs exclusively.

Diagnostic behavior

The standard Transformer Engine debug instrumentation did not reproduce the numerical explosion in this workload.

However, enabling it reduced throughput from approximately 13.0k tokens/s to 3.2k tokens/s. It also changed quantizer execution properties and substantially altered launch timing.

The debug run therefore could not exclude an execution-order-sensitive state-management issue.

Duplicate state updates were instead confirmed using lightweight Python-side counting of the actual FP8 update calls. The counter did not:

  • collect tensor values;
  • install tensor backward hooks;
  • invoke CUDA synchronization;
  • enable debug fallback paths.

Environment

Initial discovery baseline: Transformer Engine 2.16.0
Current main validation:    2.18.0.dev0+70957ad5
PyTorch:                    2.13.0+cu130
CUDA toolkit:               13.0.88
cuDNN:                      9.20.0.48
cuBLAS:                     13.1.1.3
GPU:                        NVIDIA GeForce RTX 5090 D v2, 24 GB
Compute capability:         12.0 (SM120)
Execution mode:             Single GPU

The bookkeeping failure occurs in Transformer Engine framework state management. It has been reproduced on compute capability 12.0, but there is currently no evidence that it is specific to SM120.

Type of change

  • Documentation change
  • Bug fix
  • New feature
  • Breaking change
  • Infra/Build change
  • Code refactoring

Checklist

  • I have read and followed the contributing guidelines.
  • The functionality is complete.
  • I have commented the hard-to-understand state-management logic.
  • I have made corresponding documentation changes — not applicable; no public API or user-facing behavior was added.
  • My changes generate no new warnings.
  • I have added tests that prove the fix is effective.
  • New and relevant existing unit tests pass locally with my changes.

…compute

Signed-off-by: AlbertYang514 <201034045+AlbertYang514@users.noreply.github.com>
Signed-off-by: AlbertYang514 <201034045+AlbertYang514@users.noreply.github.com>
@AlbertYang514
AlbertYang514 requested a review from ksivaman as a code owner July 15, 2026 04:44
@github-actions github-actions Bot added the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label Jul 15, 2026
@AlbertYang514 AlbertYang514 changed the title 修复激活检查点重新计算期间重复的FP8状态更新 Fix duplicate FP8 state updates during activation checkpoint recomputation Jul 15, 2026
@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a race condition in FP8 global state ownership during activation checkpoint recomputation. When DelayedScaling is used with multiple TE modules inside te.checkpoint, the previous process-global _is_first_fp8_module bookkeeping caused one extra forward FP8 state update per checkpoint segment and, under reentrant checkpointing, one extra backward update per recomputed module.

  • Core fix in distributed.py: activation_recompute_forward now captures FP8 ownership per checkpoint frame via a shared _ActivationRecomputeState dataclass, restores _FP8_ACTIVATION_RECOMPUTE_ENABLED/PHASE to their prior values on exit (rather than hard-resetting to False), and adds reserve_first_fp8_module to defer backward-ownership acquisition until the recompute phase in reentrant (no-grad) checkpoints.
  • quantization.py: A new _recompute=True flag on autocast/autocast_enter/autocast_exit prevents recompute-phase autocasts from resetting is_first_fp8_module or triggering the forward reduce_and_update_fp8_tensors call.
  • linear.py, layernorm_linear.py, layernorm_mlp.py: Removed per-module save/restore hacks for is_first_fp8_module during the recompute phase; ownership is now managed at the checkpoint frame level, not at the module level.

Confidence Score: 5/5

The change is safe to merge. It replaces a process-global ownership counter with a per-checkpoint-frame dataclass, adds a _recompute guard on the FP8 autocast, and removes the per-module save/restore hacks in three module files. All paths (reentrant, non-reentrant, nested, per-layer) produce exactly one forward and one backward FP8 state update, validated by the new regression test across 30 seeded runs.

Every affected code path has a corresponding test case; the root cause analysis matches the fix precisely; no silent fallbacks or error-swallowing were introduced; the changes are self-contained within the FP8 state management layer and do not touch public APIs, serialisation, or distributed collective logic.

No files require special attention. The one style note on _ActivationRecomputeState is cosmetic; the logic is correct as-is.

Important Files Changed

Filename Overview
transformer_engine/pytorch/distributed.py Core fix: adds per-frame _ActivationRecomputeState, per-instance save/restore of _FP8_ACTIVATION_RECOMPUTE_ENABLED/PHASE, reserve_first_fp8_module, and wires both reentrant and non-reentrant checkpoint paths to share state correctly.
transformer_engine/pytorch/quantization.py Adds _recompute flag to autocast/autocast_enter/autocast_exit; recompute autocasts skip resetting is_first_fp8_module and skip the forward reduce_and_update call on exit.
transformer_engine/pytorch/module/linear.py Removes the per-module is_first_fp8_module save/restore hack from _check_fp8_reduce_and_update; remaining use of in_fp8_activation_recompute_phase is for unrelated columnwise weight quantizer logic.
transformer_engine/pytorch/module/layernorm_linear.py Removes is_first_fp8_module save/restore for in_fp8_activation_recompute_phase; simplifies forward ownership check to a single FP8GlobalStateManager.is_first_fp8_module() call.
transformer_engine/pytorch/module/layernorm_mlp.py Removes in_fp8_activation_recompute_phase branch from conditional restore; retains is_recomputation branch for TE-internal (non-checkpoint) recomputation.
tests/pytorch/test_reentrant_fp8_updates.py New regression test covering 7 checkpoint configurations; verifies forward=1/backward=1 update counts, finite/nonzero gradients, and correct cleanup of global FP8 state after backward.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User as User code
    participant AC as te.autocast
    participant CKF as _CheckpointFunction
    participant ARF as activation_recompute_fwd
    participant RC as autocast(_recompute=True)
    participant Mod as TE Module (Linear etc.)
    participant GSM as FP8GlobalStateManager

    Note over User,GSM: --- FORWARD PASS ---
    User->>AC: "__enter__ (enabled=True)"
    AC->>GSM: "autocast_enter → is_first_fp8_module=True, depth=1"

    User->>CKF: forward() under no_grad
    CKF->>ARF: "__enter__(forward, reserve=True)"
    ARF->>ARF: "state.is_first_fp8_module = True (capture)"
    ARF->>GSM: "is_first_fp8_module = False (reserve)"

    Mod->>GSM: is_first_fp8_module() → False
    Note over Mod: ctx.reduce_and_update_bwd = False

    CKF->>ARF: __exit__(forward) no restore

    User->>AC: __exit__
    AC->>GSM: "autocast_exit → reduce_and_update(forward=True) fwd count=1"

    Note over User,GSM: --- BACKWARD PASS (loss.backward()) ---
    CKF->>ARF: __enter__(recompute, state)
    ARF->>GSM: "is_first_fp8_module = True (restore from state)"

    CKF->>RC: "__enter__(_recompute=True)"
    RC->>GSM: "autocast_enter(_recompute=True) does NOT reset is_first_fp8_module"

    Mod->>GSM: is_first_fp8_module() → True
    Note over Mod: ctx_recomputed.reduce_and_update_bwd = True

    CKF->>RC: "__exit__(_recompute=True)"
    RC->>GSM: "autocast_exit(_recompute=True) no forward update"

    CKF->>ARF: __exit__(recompute)
    ARF->>GSM: "restore _previous_is_first_fp8_module = False"

    Mod->>GSM: "reduce_and_update(forward=False) bwd count=1"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User as User code
    participant AC as te.autocast
    participant CKF as _CheckpointFunction
    participant ARF as activation_recompute_fwd
    participant RC as autocast(_recompute=True)
    participant Mod as TE Module (Linear etc.)
    participant GSM as FP8GlobalStateManager

    Note over User,GSM: --- FORWARD PASS ---
    User->>AC: "__enter__ (enabled=True)"
    AC->>GSM: "autocast_enter → is_first_fp8_module=True, depth=1"

    User->>CKF: forward() under no_grad
    CKF->>ARF: "__enter__(forward, reserve=True)"
    ARF->>ARF: "state.is_first_fp8_module = True (capture)"
    ARF->>GSM: "is_first_fp8_module = False (reserve)"

    Mod->>GSM: is_first_fp8_module() → False
    Note over Mod: ctx.reduce_and_update_bwd = False

    CKF->>ARF: __exit__(forward) no restore

    User->>AC: __exit__
    AC->>GSM: "autocast_exit → reduce_and_update(forward=True) fwd count=1"

    Note over User,GSM: --- BACKWARD PASS (loss.backward()) ---
    CKF->>ARF: __enter__(recompute, state)
    ARF->>GSM: "is_first_fp8_module = True (restore from state)"

    CKF->>RC: "__enter__(_recompute=True)"
    RC->>GSM: "autocast_enter(_recompute=True) does NOT reset is_first_fp8_module"

    Mod->>GSM: is_first_fp8_module() → True
    Note over Mod: ctx_recomputed.reduce_and_update_bwd = True

    CKF->>RC: "__exit__(_recompute=True)"
    RC->>GSM: "autocast_exit(_recompute=True) no forward update"

    CKF->>ARF: __exit__(recompute)
    ARF->>GSM: "restore _previous_is_first_fp8_module = False"

    Mod->>GSM: "reduce_and_update(forward=False) bwd count=1"
Loading

Reviews (2): Last reviewed commit: "test: tighten FP8 recompute regression c..." | Re-trigger Greptile

Comment thread tests/pytorch/test_reentrant_fp8_updates.py Outdated
Comment thread transformer_engine/pytorch/distributed.py
Comment thread tests/pytorch/test_reentrant_fp8_updates.py
Signed-off-by: AlbertYang514 <201034045+AlbertYang514@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution PRs from external contributor outside the core maintainers, representing community-driven work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant